Skip to content

GitHub User Experience Enahncement#59

Open
jmsunseri wants to merge 31 commits into
coredevices:mainfrom
jmsunseri:github-performance
Open

GitHub User Experience Enahncement#59
jmsunseri wants to merge 31 commits into
coredevices:mainfrom
jmsunseri:github-performance

Conversation

@jmsunseri
Copy link
Copy Markdown
Contributor

@jmsunseri jmsunseri commented May 25, 2026

Summary

This MR adds real-time GitHub pull and build notifications via Server-Sent Events (SSE), introduces incremental delta sync for GitHub pulls, and fixes several bugs discovered along the way.

SSE Event System

  • New SSE endpoint (/ide/project/<id>/events): Streams real-time events to the browser using Redis pub/sub. Events are published by Celery tasks (pull_start, pull_complete, pull_failed, build_start, build_complete) and delivered as properly formatted SSE named events (event: pull_start\ndata: {...}\n\n).
  • New sse.js: Connects to the SSE endpoint and dispatches events to handler functions exposed on CloudPebble.Events. Handlers update the sidebar with pending pill indicators (e.g., "Pulling...", "Building..." with animated dots) and trigger appropriate UI updates.
  • New utils/events.py: publish_event(project_id, event_type, **kwargs) utility for publishing events to Redis.

Sidebar Pending Indicators

  • Animated text pills (ShowPending/HidePending). These cycle through "Pulling" → "Pulling." → "Pulling.." → "Pulling..." and disappear cleanly on completion. The pills use the site's orange accent color (#ff4700).

GitHub Pull Improvements

  • Incremental delta sync: When pulling from GitHub, the system now compares the current commit to the new one using GitHub's compare API and only downloads/updates changed files. Falls back to full zip-based import on delta failure. The user can still force a full re-import via the checkbox.
  • Updated pull dialog: The confirmation dialog now explains delta pulling by default ("This will pull the latest changes from GitHub and apply only the files that have changed"). The overwrite warning only appears when the "Force full re-import" checkbox is checked.
  • Auto-build after pull: do_github_pull now publishes SSE events and triggers an automatic build when github_hook_build is enabled, matching the behavior of webhook-triggered pulls. Previously, manual pulls never triggered a build.
  • Bug fix: get_root_path(x) was passing GitTreeElement objects instead of x.path strings in two locations in _github_pull_full, causing delta comparisons to fail.
  • Bug fix: OnPullComplete/OnPullFailed/OnPullStart handlers referenced a local pane variable that was undefined outside show_github_pane(). Fixed to use direct jQuery selectors.
  • No page reload on pull: Instead of window.location.reload(true), OnPullComplete calls CloudPebble.Sidebar.Refresh() which silently re-fetches the file list from the server and rebuilds the sidebar without disrupting the user's current view. No navigation to the GitHub pane occurs.

Build Notifications

  • build_start events show a "Building..." pill in the sidebar. build_complete clears it.
  • Removed automatic navigation to the Build & Run pane on build_start — the pill is sufficient feedback.

Configuration & Infrastructure

  • GITHUB_HOOK_URL env var: Separate setting for GitHub webhook callback URL (via ngrok in dev), independent of PUBLIC_URL which must remain localhost for Firebase Auth.
  • nginx SSE support: Added a dedicated location block for the SSE endpoint with proxy_buffering off, proxy_cache off, proxy_read_timeout 86400, and chunked_transfer_encoding off to ensure proper streaming.
  • docker-compose.yml: Added GITHUB_HOOK_URL to environment config.
  • README.md: Added ngrok setup documentation and GITHUB_HOOK_URL instructions.

Tests

  • Python tests (test_sse.py, 20 tests): Covers SSEEventStream formatting, publish_event, the SSE view auth/project checks, hooked_commit event publishing, do_github_pull event publishing and auto-build behavior, and run_compile event publishing.
  • Python tests (test_git.py, 67 tests): Covers GetRootPath, ValidateResourcesAgainstTree, ParseManifestFromTree, PebblejsBuiltins, InferResourceKind, StripResourceDirPrefix, FetchFileContent, RemoveFileByPath, GithubPullRoutingTest (force→full, delta, fallback, no-change, etc.), GithubPullDeltaTest, ApplyDeltaChangesTest, UpsertSourceFileTest, UpsertResourceVariantTest.
  • JS tests (sse-events.test.js, 8 tests): Loads real sse.js via readFileSync+new Function(), verifies handlers call ShowPending/HidePending and OnPullStart/OnPullComplete/etc. with correct arguments.

Migration

  • 0013_github_hook_force.py: Adds github_hook_force boolean field to the Project model.

New GitHub pull settings
image

The text here changes based on the checked or unchecked status
image

Warning if you check the box when manual pull
image

Default pull now only fetches the delta
image

Off camera i'm pushing a commit to github and you can see the label on the left that github is pulling then building. Notice testing.c stays because we did not have the force check box checked.

ScreencastAutomatic.webm.mp4

Now when you do a manual pull and the setting is set to automatically build it will actually automatically build. Notice the testing.c file goes away because we had the checkbox checked

ScreencastManual.webm.mp4

jmsunseri added 15 commits May 11, 2026 19:00
Move source_files/resources deletion inside do_import_archive's
transaction.atomic() block via new wipe_existing parameter. Move
github_last_commit/github_last_sync save to after import succeeds,
preventing a failed import from recording a false commit SHA or
leaving the project in an empty state.
Remove setup_test_environment() call that conflicted with Django's
test runner (was the reason these tests were .skip-ed). Rename
test_import_archive.skip to .py. Add TestWipeExisting test class
covering the new wipe_existing parameter: replaces source files and
resources, preserves files on default (False), and rolls back on
failure.
…elpers

Start zip download in parallel with tree/manifest validation using
ThreadPoolExecutor. Move the SHA check before any network work so
unchanged repos return instantly without downloading anything.

Extract validate_resources_against_tree() and parse_manifest_from_tree()
as pure functions from github_pull. Extract PEBBLEJS_BUILTIN_RESOURCES
as a frozenset constant. Add 24 unit tests covering get_root_path,
resource validation, Pebble.js builtins, and manifest parsing including
edge cases for variant suffixes, missing resources, subdirectories,
package.json, and invalid JSON.
When github_pull detects a change between the stored commit and the
remote branch, it now uses GitHub's Compare API to fetch only the files
that changed, then applies minimal DB updates (add/modify/remove
SourceFile and ResourceFile/ResourceVariant records) within an atomic
transaction.

A force=True parameter (exposed via checkbox in the pull modal) falls
back to the existing full-zip wipe-and-replace path. This also serves
as the path for initial pulls where github_last_commit is None.

Extracted testable helpers: validate_resources_against_tree,
parse_manifest_from_tree, _apply_delta_changes, _upsert_source_file,
_upsert_resource_variant, _remove_file_by_path,
_sync_resource_files_from_manifest, _fetch_file_content,
_infer_resource_kind_from_path, _strip_resource_dir_prefix.

Added 47 unit tests covering the new helpers, including
FetchFileContentTest and RemoveFileByPathTest with mocked GitHub API.

Fixed Django template syntax in github-pull-prompt modal.
Refactor SSE event handlers into a separate module for
testability. Export handlers from the SSE module and add
unit tests for both the JavaScript event handlers and the
Python SSE event publishing logic.
- Emit proper SSE event types instead of embedding in JSON data
- Replace spinning icon with animated "Pulling"/"Building" pending pill
- Close pull prompt immediately on confirm; remove page reload hack
- Move force-pull warning behind checkbox toggle
- Auto-trigger build after webhook-initiated pull
- Add GITHUB_HOOK_URL setting for ngrok in local development
- Fix GitTree attribute access (x.path instead of x)
Moves the full page reload logic from OnPullComplete into a new
CloudPebble.Sidebar.Refresh function. This updates project info
and repopulates source/resource lists without losing editor state.

Also removes `if (pane)` guards in github.js and adds docstrings
to git test methods.
@jmsunseri jmsunseri changed the title GitHub performance GitHub User Experience Enahncement May 25, 2026
@jmsunseri jmsunseri marked this pull request as ready for review May 25, 2026 11:07
@jmsunseri
Copy link
Copy Markdown
Contributor Author

The tests are still failing because of a permissions issue regarding the pipeline adding comments to the github PR. Maybe this would work if it were coming from a branch on this repo and not an outside fork?

@jmsunseri
Copy link
Copy Markdown
Contributor Author

@ericmigi and @jplexer this is ready for a review. Should be a great improvement to the UX of the github flow.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a real-time event pipeline (Redis pub/sub → Django SSE → browser EventSource) to surface GitHub pull/build status in the UI, and refactors GitHub pulls to support incremental “delta” sync (with a full re-import fallback/option) plus auto-build behavior parity with webhook-triggered pulls.

Changes:

  • Added SSE infrastructure: Redis event publisher, authenticated SSE endpoint, nginx streaming support, and browser-side sse.js handlers wired into the project page.
  • Implemented GitHub delta pull flow and a “wipe on pull” option (full re-import via wipe_existing=True), plus additional tests around SSE, git sync, and archive import semantics.
  • Updated UI/UX: sidebar pending indicators, improved pull confirmation dialog, GitHub pane settings and status display, and “no full page reload” sidebar refresh.

Reviewed changes

Copilot reviewed 28 out of 29 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
README.md Documents GITHUB_HOOK_URL and ngrok workflow for local webhook callbacks.
nginx/nginx.conf Adds a streaming-friendly nginx location for the SSE endpoint.
docker-compose.yml Adds optional .env.local loading for web and celery.
cloudpebble/utils/events.py Introduces Redis pub/sub event publishing helper.
cloudpebble/ide/utils/cloudpebble_test.py Removes explicit test environment setup call.
cloudpebble/ide/urls.py Registers the new per-project SSE endpoint route.
cloudpebble/ide/tests/test_sse.py Adds unit tests for SSE formatting, auth, and event publishing from tasks.
cloudpebble/ide/tests/test_import_archive.py Adds tests for wipe_existing behavior and rollback safety.
cloudpebble/ide/tests/test_git.py Adds extensive tests around git pull routing and delta sync helpers.
cloudpebble/ide/templates/ide/project/github.html Adds “wipe on auto-pull” UI and GitHub last-sync display area.
cloudpebble/ide/templates/ide/project.html Updates pull modal copy/controls and includes sse.js.
cloudpebble/ide/tasks/git.py Implements delta sync, force/full pull routing, and publishes pull/build SSE events.
cloudpebble/ide/tasks/build.py Publishes build completion SSE events.
cloudpebble/ide/tasks/archive.py Adds wipe_existing option to delete existing files/resources during import.
cloudpebble/ide/static/ide/js/sse.js Adds client-side SSE connection and event dispatch to existing handlers.
cloudpebble/ide/static/ide/js/sidebar.js Adds pending-pill indicator helpers and a sidebar “refresh” method.
cloudpebble/ide/static/ide/js/github.js Updates GitHub pane UI, adds pull force option, and SSE-driven pull callbacks.
cloudpebble/ide/static/ide/js/compile.js Adds compile pane hooks for build start/complete events.
cloudpebble/ide/static/ide/js/cloudpebble.js Initializes the SSE client during project init.
cloudpebble/ide/static/ide/js/tests/sse-events.test.js Adds JS tests verifying SSE handler behavior.
cloudpebble/ide/static/ide/css/ide.css Styles the sidebar pending pill.
cloudpebble/ide/models/project.py Adds github_hook_force project setting.
cloudpebble/ide/migrations/0013_github_hook_force.py Migrates the new github_hook_force field.
cloudpebble/ide/api/sse.py Adds authenticated SSE streaming endpoint implementation.
cloudpebble/ide/api/project.py Exposes hook_force in project info payload.
cloudpebble/ide/api/git.py Adds force/full-pull parameter plumbing and persists hook-force setting.
cloudpebble/cloudpebble/settings.py Adds GITHUB_HOOK_URL override for webhook callback base URL.
cloudpebble/.gitignore Ignores node_modules/ in the cloudpebble subdir.
.gitignore Ignores .env.local at repo root.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread README.md Outdated
Comment thread cloudpebble/ide/tasks/git.py Outdated
Comment on lines +338 to +347
resource_root = project.resources_path + '/'
manifest_resources = manifest.get('resources', {}).get('media', [])
project_type = manifest.get('projectType', 'native')

for resource in manifest_resources:
path = resource_root + resource['file']
if project_type == 'pebblejs' and resource['name'] in PEBBLEJS_BUILTIN_RESOURCES:
continue
if path not in paths_notags:
raise Exception("Resource %s not found in repo." % path)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be resolved now

Comment on lines +473 to +505
manifest_kind = 'package.json' if 'pebble' in manifest else 'appinfo.json'
resource_root = project.resources_path + '/'

with transaction.atomic():
project_options, media_map, dependencies = load_manifest_dict(manifest, manifest_kind)

for k, v in project_options.items():
setattr(project, k, v)
project.full_clean()
project.set_dependencies(dependencies)

tag_map = {v: k for k, v in ResourceVariant.VARIANT_STRINGS.items() if v}

existing_sources = {s.project_path: s for s in project.source_files.all()}
existing_resources = {r.file_name: r for r in project.resources.all()}

for change in changed_files:
filename = change.filename
status = change.status

if status in ('added', 'modified', 'renamed'):
if status == 'renamed' and change.previous_filename:
_remove_file_by_path(project, change.previous_filename, existing_sources, existing_resources)

if filename.startswith(resource_root):
_upsert_resource_variant(project, repo, change, existing_resources, tag_map)
else:
try:
base_filename, target = SourceFile.get_details_for_path(project.project_type, filename)
_upsert_source_file(project, repo, change, base_filename, target, existing_sources)
except ValueError:
logger.debug("Skipping unrecognized file in delta: %s", filename)
continue
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be resolved now

Comment thread cloudpebble/ide/tasks/git.py Outdated
tag_map = {v: k for k, v in ResourceVariant.VARIANT_STRINGS.items() if v}

existing_sources = {s.project_path: s for s in project.source_files.all()}
existing_resources = {r.file_name: r for r in project.resources.all()}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be resolved now

Comment on lines +511 to +514

project.save()


Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be resolved now

Comment on lines +317 to +336
CloudPebble.Editor.GetUnsavedFiles = function() { return 0; };
$('#sidebar-sources').empty();
$('#sidebar-resources').empty();
create_initial_sections(CloudPebble.ProjectInfo.type);
Ajax.Get('/ide/project/' + PROJECT_ID + '/info').then(function(data) {
CloudPebble.ProjectInfo = data;
var is_alloy = data.type === 'alloy';
$.each(data.source_files, function(index, value) {
if (is_alloy && value.target === 'embeddedjs' && value.is_binary) {
if (CloudPebble.Resources && _.isFunction(CloudPebble.Resources.AddAlloyAsset)) {
CloudPebble.Resources.AddAlloyAsset(value);
}
return;
}
CloudPebble.Editor.Add(value);
});
$.each(data.resources, function(index, value) {
CloudPebble.Resources.Add(value);
});
});
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be resolved now

Comment thread cloudpebble/ide/static/ide/js/github.js
Comment thread cloudpebble/ide/static/ide/js/github.js Outdated
Comment on lines 31 to 48
@@ -43,6 +44,7 @@ def set_project_repo(request, project_id):
branch = request.POST['branch']
auto_pull = bool(int(request.POST['auto_pull']))
auto_build = bool(int(request.POST['auto_build']))
hook_force = bool(int(request.POST.get('hook_force', '0')))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be fixed now

Comment on lines +9 to +14
def publish_event(project_id, event_type, **kwargs):
data = {'type': event_type}
data.update(kwargs)
channel = 'project_events:{}'.format(project_id)
redis_client.publish(channel, json.dumps(data))
logger.debug("Published %s to %s", event_type, channel) No newline at end of file
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be fixed now

jmsunseri added 8 commits May 26, 2026 03:57
The validation function now supports `package.json` manifests
where resources are nested under the `pebble` key. It also
correctly handles projects cloned into a subdirectory by
prepending the `root` path to the resource lookup.

Fix a trailing parenthesis in README.
Use the full manifest path (including the resource directory prefix)
when keying the existing resources dictionary, so that delta changes
are correctly matched against the modified file paths.
When applying delta changes, pass media_map to
_upsert_resource_variant so the correct resource kind is
used from the manifest instead of inferring from the path.
When syncing from manifest, update kind and menuIcon on
existing resources if they differ.
jmsunseri added 6 commits May 26, 2026 04:35
Catch RedisError, ConnectionError and generic connection
failures to prevent crashes when Redis is unavailable.
Use try/finally in the done callback to guarantee
GetUnsavedFiles is restored even if an error is thrown.
Also restore it on ajax failure.
@jmsunseri
Copy link
Copy Markdown
Contributor Author

@jplexer i think i have remedied all of the comments made by autipilot. Can you have it re-review to see if it thinks all of its concerns have been satisfied?

@ericmigi ericmigi requested a review from jplexer May 29, 2026 05:24
@ericmigi
Copy link
Copy Markdown
Contributor

@jplexer can you try deploying to a dev instance and double checking it works pls

jmsunseri added 2 commits June 2, 2026 20:04
Move was_clean to code_mirror instance so the state survives editor
detach. Only reload the active buffer when it has no unsaved edits,
preventing user work from being clobbered after a GitHub pull.
Wrap github_last_commit and github_last_sync saves in
transaction.atomic() across full, delta, and no-new-commit
pull paths so a partial failure never leaves the project
with new files but the old SHA.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants